feat(show): crop kwarg for on-the-fly bounding-box rendering - #765
Conversation
Add show(crop=(xmin, xmax, ymin, ymax)) to restrict a plot to a bounding box in the rendered coordinate system, addressing #764. - points/shapes: cheap per-element predicate subset before drawing (radius-aware for circles); auto-scaled color domain taken from the full element so colors match the uncropped plot. - images: rasterize only the crop window via rasterize()'s target bbox, so the full image is never materialized (fast at Visium HD scale) and the zoom keeps full figure resolution. Placement is correct without a transform rewrite. Contrast auto-scales over the window unless vmin/vmax or a norm is given. - labels: drawn in full and clipped to the box (windowed label rendering needs full-element palette handling; deferred). - exact axis limits (bypass extent-union merge, honor inverted y-axis, ignore pad_extent); validated single-coordinate-system. Unit tests cover the bbox helpers, points/shapes subset, full-element color domain, image window-rasterization, and validation errors.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #765 +/- ##
==========================================
+ Coverage 79.66% 79.93% +0.26%
==========================================
Files 18 18
Lines 4672 4814 +142
Branches 1036 1069 +33
==========================================
+ Hits 3722 3848 +126
- Misses 599 604 +5
- Partials 351 362 +11
🚀 New features to boost your workflow:
|
Follow-up to the max code review of the crop feature. - Rename public kwarg crop -> crop_coord to match scanpy sc.pl.spatial / squidpy sq.pl.spatial_scatter (interop > novelty); 4-tuple unchanged. - Norm handling is now non-mutating: capture the full-element range and apply it only on the matplotlib backend. Fixes the datashader leak (it now autoscales over the window, correct for sum/count) and the pre-transfunc pin (range is taken over the transfunc'd full vector). - _bbox_mask_shapes is radius-aware per row, so a frame mixing circles and polygons no longer drops edge circles. - Multiscale images pick the pyramid level for the crop WINDOW's required resolution, not the full image, so a Visium-HD crop no longer upsamples a coarse level. - Document that labels and method='datashader' images are clip-only under crop (windowed labels need full-element palette handling; deferred). - Simplify the crop axis-limit block to read crop_coord directly. Tests: transfunc-matches-uncropped, datashader-autoscales-over-window, multiscale-selects-finer-level, mixed circle+polygon mask.
|
Follow-up API naming — renamed the kwarg Correctness
Scope/docs — Tests added (the review's gap): transfunc-matches-uncropped, datashader-autoscales-over-window, multiscale-selects-finer-level, mixed circle+polygon mask. Full non-visual suite: 361 passed. Remaining follow-ups: windowed labels, datashader-image window path (materializes the full image in the |
Previously method='datashader' images ignored crop_coord (full render + axis-clip). Now the datashader aggregation is restricted to the window: rasterize() supplies the correct window coords/transform and only the sliced source is materialized, so the full image is never read — this scales to Visium HD (prototype: peak memory 258 -> 19 MB on 8k^2, and O(window) instead of O(image)). The datashader reduction (max/sum/...) is preserved by aggregating the sliced source in pixel space and index- assigning onto the rasterized grid. Falls back to full render for rotation/shear or an empty window. New _datashader_window_image helper in utils.py; regression test asserts the window is rasterized at figure resolution, not the full source.
|
Follow-up
Prototype ( Regression test added; full non-visual suite still green (227 image/utils + 12 crop). Only labels remain clip-only under crop now (windowing them needs full-element categorical-palette handling) — documented as a follow-up. |
Labels were the last element still full-render + axis-clip under crop_coord. Thread crop into _render_labels so the label raster is windowed via the same crop-aware _rasterize_if_necessary / _multiscale_to_spatial_image paths as images (full array never materialized; multiscale picks the level for the window). Colour stability: windowing drops off-window instances before colours resolve. That is stable for numeric columns, pandas-Categorical columns (fixed levels) and explicit palettes; a plain-string column with the default palette would reshuffle colours as whole categories drop out of the window, so that case falls back to full render + clip. Empty-window guard returns before the instance-overlap/colour machinery when the box covers no labels. Tests: placement + empty window; no-colour-reshuffle for categorical (windowed) and string (fallback). Full non-visual suite: 390 passed.
|
Follow-up
Colour stability (the reshuffle concern from the review): windowing drops off-window instances before colours resolve. That's stable for numeric columns, pandas-Categorical columns (fixed levels) and explicit palettes. A plain-string column with the default palette would reshuffle (as whole categories drop out of the window their sorted positions shift), so that specific case falls back to full render + clip — colours always match the uncropped plot; use a categorical dtype or an explicit palette to keep the windowed fast path. An empty-window guard returns cleanly when the box covers no labels. Tests: placement + empty-window; no-colour-reshuffle for categorical (windowed) and string (fallback). Full non-visual suite: 390 passed. Every element type now has the crop fast path: points/shapes (predicate subset), images + datashader-images (window rasterize), labels (window rasterize with the string-palette fallback). Only rotation/shear and multi-CS per-box remain as documented follow-ups. |
Cleanup from a quality review of the crop feature: - Extract _rasterize_to_bbox and use it in both _rasterize_if_necessary (replacing the raster_extent/do_rasterization branching) and _datashader_window_image, so 'rasterize a bbox to figure resolution' lives in one place. - Drop _datashader_window_image's redundant has_c_dim/x_dims/y_dims params (recomputed from image at the top). - Resolve the full-element crop norm range lazily on the matplotlib branch instead of eagerly in the crop block, so the datashader (large-data) path no longer pays a full-element min/max scan it never uses.
Add TestShow.test_plot_crop_{image,points,shapes,circles,labels,
layered_elements} exercising crop_coord on the blobs dataset. Reference
images are generated on CI and added in a follow-up commit (baselines
must match the CI matplotlib, not a local render).
Reference images for TestShow.test_plot_crop_* rendered by the hatch-test CI matrix and downloaded from the visual_test_results artifact.
Behavior-preserving cleanup of the crop_coord feature, plus one correctness fix in the datashader window path. - BBox NamedTuple (render_params): one (x0, y0, x1, y1) ordering travels through every crop helper instead of three names/orders (crop_coord at the API, crop_box in show(), crop downstream); show() converts the public (xmin, xmax, ymin, ymax) once at the boundary. Removes the per-helper order-restating comments. - Thread crop via FigParams.crop instead of a kwarg on _render_panel and all four render fns; drops the kwargs['crop'] special-case. - Dedup the axis-limit block (compute limits, set once). - Extract _pin_norm_to_full_range (shared by _render_points/_render_shapes) and _crop_color_is_stable (labels windowing predicate). - Harden _datashader_window_image: base and the datashader aggregate now cover the same integer pixel window (mapped back to world via the forward affine), fixing a sub-pixel misalignment and an edge-clamp case where the fractional canvas range overran the sliced source; materialize base before overwriting; drop the dead 2D branch (images always carry a c dim). Tests: BBox is a tuple subclass so existing helper tests are unchanged; add datashader window placement/clamp/empty-window tests. Full non-visual pl suite: 550 passed.
# Conflicts: # tests/pl/test_show.py
Closes #764.
Adds
show(crop_coord=(xmin, xmax, ymin, ymax))to restrict a plot to a bounding box in the rendered coordinate system's units. Limits both the data drawn and the rasterized canvas, so large elements are not fully materialized before being clipped. Namedcrop_coordto matchsc.pl.spatial/sq.pl.spatial_scatter(same 4-tuple order).Per element type
rasterize()'s target bbox. The full image is never materialized and the window keeps full figure resolution. Datashader images aggregate only the sliced source, preserving the configured reduction. Contrast auto-scales over the window (vmin/vmax/normto override).Axis limits are set to the exact box (bypasses the extent-union merge, honours the inverted y-axis, ignores
pad_extent). Restricted to a single coordinate system (validated).Rotation/shear falls back to full render + clip (a box does not map to a box). Multi-coordinate-system crop with a per-CS box is not implemented.
Benchmarks
Prototype scripts, 3% window, image carrying a Scale+Translation. Two things measured: matplotlib image rasterization time vs image size, and datashader-image peak memory.
Matplotlib image, window rasterize vs full-render-then-clip (wall time):
Window cost is flat (~16 ms) because only the window's source pixels are read; the full path grows with image size and would OOM at Visium HD scale.
Datashader image (8000² sparse): peak memory 258 MB → 19 MB, since the full image is no longer passed through
image.compute().Tests
tests/pl/test_show.py,tests/pl/test_utils.py: bbox helpers (negative-scale re-sort, rotation → None, radius-aware + mixed circle/polygon), points/shapes subset, full-element colour domain, transfunc match, datashader window autoscale, multiscale level selection, image and datashader-image window rasterization, labels placement / empty-window / no-colour-reshuffle. Full non-visualplsuite: 547 passed.